// This file is part of Keepass2Android, Copyright 2025 Philipp Crocoll.
//
//   Keepass2Android is free software: you can redistribute it and/or modify
//   it under the terms of the GNU General Public License as published by
//   the Free Software Foundation, either version 3 of the License, or
//   (at your option) any later version.
//
//   Keepass2Android is distributed in the hope that it will be useful,
//   but WITHOUT ANY WARRANTY; without even the implied warranty of
//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//   GNU General Public License for more details.
//
//   You should have received a copy of the GNU General Public License
//   along with Keepass2Android.  If not, see <http://www.gnu.org/licenses/>.

#if !NoNet
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Net;
using Android.Content;
using Android.OS;
using FluentFTP;
using FluentFTP.Exceptions;
using KeePass.Util;
using KeePassLib;
using KeePassLib.Serialization;
using KeePassLib.Utility;


namespace keepass2android.Io
{
  public class NetFtpFileStorage : IFileStorage
  {
    public struct ConnectionSettings
    {
      public FtpEncryptionMode EncryptionMode { get; set; }

      public string Username
      {
        get; set;
      }
      public string Password
      {
        get;
        set;
      }

      public static ConnectionSettings FromIoc(IOConnectionInfo ioc)
      {
        if (!string.IsNullOrEmpty(ioc.UserName))
        {
          //legacy support
          return new ConnectionSettings()
          {
            EncryptionMode = FtpEncryptionMode.None,
            Username = ioc.UserName,
            Password = ioc.Password
          };
        }

        string path = ioc.Path;
        int schemeLength = path.IndexOf("://", StringComparison.Ordinal);
        path = path.Substring(schemeLength + 3);
        string settings = path.Substring(0, path.IndexOf(SettingsPostFix, StringComparison.Ordinal));
        if (!settings.StartsWith(SettingsPrefix))
          throw new Exception("unexpected settings in path");
        settings = settings.Substring(SettingsPrefix.Length);
        var tokens = settings.Split(Separator);
        return new ConnectionSettings()
        {
          EncryptionMode = (FtpEncryptionMode)int.Parse(tokens[2]),
          Username = WebUtility.UrlDecode(tokens[0]),
          Password = WebUtility.UrlDecode(tokens[1])
        };

      }

      public const string SettingsPrefix = "SET";
      public const string SettingsPostFix = "#";
      public const char Separator = ':';
      public override string ToString()
      {
        return SettingsPrefix +
            System.Net.WebUtility.UrlEncode(Username) + Separator +
            WebUtility.UrlEncode(Password) + Separator +
               (int)EncryptionMode;
        ;
      }
    }

    private readonly ICertificateValidationHandler _app;
    private readonly Func<bool> _debugLogPrefGetter;

    public MemoryStream traceStream;

    public NetFtpFileStorage(Context context, ICertificateValidationHandler app, Func<bool> debugLogPrefGetter)
    {
      _app = app;
      _debugLogPrefGetter = debugLogPrefGetter;
      traceStream = new MemoryStream();
    }

    public IEnumerable<string> SupportedProtocols
    {
      get
      {
        yield return "ftp";
      }
    }

    public bool UserShouldBackup
    {
      get { return true; }
    }

    public void Delete(IOConnectionInfo ioc)
    {
      try
      {
        using (FtpClient client = GetClient(ioc))
        {
          string localPath = IocToLocalPath(ioc);
          if (client.DirectoryExists(localPath))
            client.DeleteDirectory(localPath);
          else
            client.DeleteFile(localPath);
        }

      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }

    }

    public static Exception ConvertException(Exception exception)
    {
      if (exception is FtpCommandException)
      {
        var ftpEx = (FtpCommandException)exception;

        if (ftpEx.CompletionCode == "550")
          throw new FileNotFoundException(ExceptionUtil.GetErrorMessage(exception), exception);
      }

      return exception;
    }


    internal FtpClient GetClient(IOConnectionInfo ioc, bool enableCloneClient = true)
    {
      var settings = ConnectionSettings.FromIoc(ioc);

      FtpClient client = new FtpClient();
      client.Config.RetryAttempts = 3;
      if ((settings.Username.Length > 0) || (settings.Password.Length > 0))
        client.Credentials = new NetworkCredential(settings.Username, settings.Password);
      else
        client.Credentials = new NetworkCredential("anonymous", ""); //TODO TEST

      Uri uri = IocToUri(ioc);
      client.Host = uri.Host;
      if (!uri.IsDefaultPort) //TODO test
        client.Port = uri.Port;

      client.ValidateCertificate += (control, args) =>
      {
        args.Accept = _app.CertificateValidationCallback(control, args.Certificate, args.Chain, args.PolicyErrors);
      };

      client.Config.EncryptionMode = settings.EncryptionMode;

      if (_debugLogPrefGetter())
        client.Logger = new Kp2aLogFTPLogger();

      client.Connect();
      return client;

    }




    public static Uri IocToUri(IOConnectionInfo ioc)
    {
      if (!string.IsNullOrEmpty(ioc.UserName))
      {
        //legacy support.
        return new Uri(ioc.Path);
      }
      string path = ioc.Path;
      //remove additional stuff like TLS param
      int schemeLength = path.IndexOf("://", StringComparison.Ordinal);
      string scheme = path.Substring(0, schemeLength);
      path = path.Substring(schemeLength + 3);
      if (path.StartsWith(ConnectionSettings.SettingsPrefix))
      {
        //this should always be the case. However, in rare cases we might get an ioc with legacy path but no username set (if they only want to get a display name)
        string settings = path.Substring(0, path.IndexOf(ConnectionSettings.SettingsPostFix, StringComparison.Ordinal));
        path = path.Substring(settings.Length + 1);

      }
      Kp2aLog.Log("FTP: IocToUri out = " + scheme + "://" + path);
      return new Uri(scheme + "://" + path);
    }

    private string IocPathFromUri(IOConnectionInfo baseIoc, string uri)
    {
      string basePath = baseIoc.Path;
      int schemeLength = basePath.IndexOf("://", StringComparison.Ordinal);
      string scheme = basePath.Substring(0, schemeLength);
      basePath = basePath.Substring(schemeLength + 3);
      string baseSettings = basePath.Substring(0, basePath.IndexOf(ConnectionSettings.SettingsPostFix, StringComparison.Ordinal));
      basePath = basePath.Substring(baseSettings.Length + 1);
      string baseHost = basePath.Substring(0, basePath.IndexOf("/", StringComparison.Ordinal));
      string result = scheme + "://" + baseSettings + ConnectionSettings.SettingsPostFix + baseHost + uri; //TODO does this contain Query?
      return result;
    }


    public bool CheckForFileChangeFast(IOConnectionInfo ioc, string previousFileVersion)
    {
      return false;
    }

    public string GetCurrentFileVersionFast(IOConnectionInfo ioc)
    {
      return null;
    }

    public Stream OpenFileForRead(IOConnectionInfo ioc)
    {
      try
      {
        using (var cl = GetClient(ioc))
        {
          var memStream = new MemoryStream();
          cl.OpenRead(IocToLocalPath(ioc), FtpDataType.Binary, 0).CopyTo(memStream);
          memStream.Seek(0, SeekOrigin.Begin);
          return memStream;
        }
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public IWriteTransaction OpenWriteTransaction(IOConnectionInfo ioc, bool useFileTransaction)
    {
      try
      {


        if (!useFileTransaction)
          return new UntransactedWrite(ioc, this);
        else
          return new TransactedWrite(ioc, this);
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public string GetFilenameWithoutPathAndExt(IOConnectionInfo ioc)
    {
      return UrlUtil.StripExtension(
          UrlUtil.GetFileName(ioc.Path));
    }

    public string GetFileExtension(IOConnectionInfo ioc)
    {
      return UrlUtil.GetExtension(ioc.Path);
    }

    public bool RequiresCredentials(IOConnectionInfo ioc)
    {
      return false;
    }

    public void CreateDirectory(IOConnectionInfo ioc, string newDirName)
    {
      try
      {
        using (var client = GetClient(ioc))
        {
          client.CreateDirectory(IocToLocalPath(GetFilePath(ioc, newDirName)));
        }
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public static string IocToLocalPath(IOConnectionInfo ioc)
    {
      return WebUtility.UrlDecode(IocToUri(ioc).PathAndQuery);
    }

    public IEnumerable<FileDescription> ListContents(IOConnectionInfo ioc)
    {
      try
      {
        using (var client = GetClient(ioc))
        {
          /*
           * For some reason GetListing(path) does not always return the contents of the directory.
           * However, calling SetWorkingDirectory(path) followed by GetListing(null, options) to
           * list the contents of the working directory does consistently work.
           * 
           * Similar behavior was confirmed using ncftp client. I suspect this is a strange
           * bug/nuance in the server's implementation of the LIST command?
           *
           * [bug #2423]
           */
          client.SetWorkingDirectory(IocToLocalPath(ioc));

          List<FileDescription> files = new List<FileDescription>();
          foreach (FtpListItem item in client.GetListing(null,
              FtpListOption.SizeModify | FtpListOption.AllFiles))
          {
            switch (item.Type)
            {
              case FtpObjectType.Directory:
                files.Add(new FileDescription()
                {
                  CanRead = true,
                  CanWrite = true,
                  DisplayName = item.Name,
                  IsDirectory = true,
                  LastModified = item.Modified,
                  Path = IocPathFromUri(ioc, item.FullName)
                });
                break;
              case FtpObjectType.File:
                files.Add(new FileDescription()
                {
                  CanRead = true,
                  CanWrite = true,
                  DisplayName = item.Name,
                  IsDirectory = false,
                  LastModified = item.Modified,
                  Path = IocPathFromUri(ioc, item.FullName),
                  SizeInBytes = item.Size
                });
                break;
              default:
                Kp2aLog.Log("FTP: ListContents item skipped: " + IocToUri(ioc) + ": " + item.FullName + ", type=" + item.Type);
                break;
            }
          }
          return files;
        }
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public FileDescription GetFileDescription(IOConnectionInfo ioc)
    {
      try
      {
        //TODO when is this called? 
        //is it very inefficient to connect for each description?

        using (FtpClient client = GetClient(ioc))
        {


          string path = IocToLocalPath(ioc);
          if (!client.FileExists(path) && (!client.DirectoryExists(path)))
            throw new FileNotFoundException();
          var fileDesc = new FileDescription()
          {
            CanRead = true,
            CanWrite = true,
            Path = ioc.Path,
            LastModified = client.GetModifiedTime(path),
            SizeInBytes = client.GetFileSize(path),
            DisplayName = UrlUtil.GetFileName(path)
          };
          fileDesc.IsDirectory = fileDesc.Path.EndsWith("/");
          return fileDesc;
        }
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public bool RequiresSetup(IOConnectionInfo ioConnection)
    {
      return false;
    }

    public string IocToPath(IOConnectionInfo ioc)
    {
      return ioc.Path;
    }

    public void StartSelectFile(IFileStorageSetupInitiatorActivity activity, bool isForSave, int requestCode, string protocolId)
    {
      activity.PerformManualFileSelect(isForSave, requestCode, "ftp");
    }

    public void PrepareFileUsage(IFileStorageSetupInitiatorActivity activity, IOConnectionInfo ioc, int requestCode,
        bool alwaysReturnSuccess)
    {
      Intent intent = new Intent();
      activity.IocToIntent(intent, ioc);
      activity.OnImmediateResult(requestCode, (int)FileStorageResults.FileUsagePrepared, intent);
    }

    public void PrepareFileUsage(Context ctx, IOConnectionInfo ioc)
    {

    }

    public void OnCreate(IFileStorageSetupActivity activity, Bundle savedInstanceState)
    {

    }

    public void OnResume(IFileStorageSetupActivity activity)
    {

    }

    public void OnStart(IFileStorageSetupActivity activity)
    {

    }

    public void OnActivityResult(IFileStorageSetupActivity activity, int requestCode, int resultCode, Intent data)
    {

    }

    public string GetDisplayName(IOConnectionInfo ioc)
    {
      var uri = IocToUri(ioc);
      return uri.ToString(); //TODO is this good?
    }

    public string CreateFilePath(string parent, string newFilename)
    {
      if (!parent.EndsWith("/"))
        parent += "/";
      return parent + newFilename;
    }

    public IOConnectionInfo GetParentPath(IOConnectionInfo ioc)
    {
      return IoUtil.GetParentPath(ioc);
    }

    public IOConnectionInfo GetFilePath(IOConnectionInfo folderPath, string filename)
    {
      IOConnectionInfo res = folderPath.CloneDeep();
      if (!res.Path.EndsWith("/"))
        res.Path += "/";
      res.Path += filename;
      return res;
    }

    public bool IsPermanentLocation(IOConnectionInfo ioc)
    {
      return true;
    }

    public bool IsReadOnly(IOConnectionInfo ioc, OptionalOut<UiStringKey> reason = null)
    {
      return false;
    }
    public Stream OpenWrite(IOConnectionInfo ioc)
    {
      try
      {
        using (var client = GetClient(ioc))
        {
          return client.OpenWrite(IocToLocalPath(ioc));

        }
      }
      catch (FtpCommandException ex)
      {
        throw ConvertException(ex);
      }
    }

    public static int GetDefaultPort(FtpEncryptionMode encryption)
    {
      var client = new FtpClient();
      client.Config.EncryptionMode = encryption;
      return client.Port;
    }

    public string BuildFullPath(string host, int port, string initialPath, string user, string password, FtpEncryptionMode encryption)
    {
      var connectionSettings = new ConnectionSettings()
      {
        EncryptionMode = encryption,
        Username = user,
        Password = password
      };

      string scheme = "ftp";

      string fullPath = scheme + "://" + connectionSettings.ToString() + ConnectionSettings.SettingsPostFix + host;
      if (port != GetDefaultPort(encryption))
        fullPath += ":" + port;

      if (!initialPath.StartsWith("/"))
        initialPath = "/" + initialPath;
      fullPath += initialPath;

      return fullPath;
    }

  }

  public class TransactedWrite : IWriteTransaction
  {
    private readonly IOConnectionInfo _ioc;
    private readonly NetFtpFileStorage _fileStorage;
    private readonly IOConnectionInfo _iocTemp;
    private FtpClient _client;
    private Stream _stream;

    public TransactedWrite(IOConnectionInfo ioc, NetFtpFileStorage fileStorage)
    {
      _ioc = ioc;
      _iocTemp = _ioc.CloneDeep();
      _iocTemp.Path += "." + new PwUuid(true).ToHexString().Substring(0, 6) + ".tmp";

      _fileStorage = fileStorage;
    }

    public void Dispose()
    {
      if (_stream != null)
        _stream.Dispose();
      _stream = null;
    }

    public Stream OpenFile()
    {
      try
      {

        _client = _fileStorage.GetClient(_ioc, false);
        _stream = _client.OpenWrite(NetFtpFileStorage.IocToLocalPath(_iocTemp));
        return _stream;
      }
      catch (FtpCommandException ex)
      {
        throw NetFtpFileStorage.ConvertException(ex);
      }
    }

    public void CommitWrite()
    {
      try
      {
        Android.Util.Log.Debug("NETFTP", "connected: " + _client.IsConnected.ToString());
        _stream.Close();
        _stream.Dispose();
        _client.GetReply();

        _client.MoveFile(NetFtpFileStorage.IocToLocalPath(_iocTemp),
            NetFtpFileStorage.IocToLocalPath(_ioc));

      }
      catch (FtpCommandException ex)
      {
        throw NetFtpFileStorage.ConvertException(ex);
      }
    }
  }

  public class UntransactedWrite : IWriteTransaction
  {
    private readonly IOConnectionInfo _ioc;
    private readonly NetFtpFileStorage _fileStorage;
    private Stream _stream;

    public UntransactedWrite(IOConnectionInfo ioc, NetFtpFileStorage fileStorage)
    {
      _ioc = ioc;
      _fileStorage = fileStorage;
    }

    public void Dispose()
    {
      if (_stream != null)
        _stream.Dispose();
      _stream = null;
    }

    public Stream OpenFile()
    {
      _stream = _fileStorage.OpenWrite(_ioc);
      return _stream;
    }

    public void CommitWrite()
    {
      _stream.Close();
    }
  }

  class Kp2aLogFTPLogger : IFtpLogger
  {
    public void Log(FtpLogEntry entry)
    {
      Kp2aLog.Log("[FluentFTP] " + entry.Message);
    }
  }
}
#endif